目前為止我們已經有了(差強人意的)readelf、objdump、nm、和 as。隨著鐵人賽接近尾聲,筆者決定將時間投資在最沒有確定性的最後一片拼圖上:連結器。今天就讓我們開始認識連結這個動作的重要性。
筆者多年前還在念碩士班的時候,有一天有個隔壁實驗室的新生跑來問問題,因為那天他們那裡還沒有學長姊出席讓他可以諮詢,老師也沒有明確的任務下達,所以就變成來問我了。他的情況特殊,原本是外系的學生,碩班才轉到資工來,會寫一些 C 語言的程度。
他的問題是這樣的:「有沒有什麼參考資料可以告訴我,電腦從開機開始之後作的每一件事情依序是什麼?有沒有什麼工具可以讓我看看記憶體裡面任意位置是什麼樣的資料?程式從寫出來到編譯完再到可以跑,中間發生了什麼事情?」說來慚愧,筆者當時的回答是:「這些問題都非常大,你要不要跟你們老師確認一下明確的研究內容是什麼?」
真所謂他山之石可以攻玉,至少筆者身邊曾經都是資工領域的同學,卻從來沒有聽過有誰問過這些問題。如果沒有問過問題是因為知道答案那也就罷了,偏偏至少筆者自己當時根本不知道這些大哉問該從何答起。到了今天,筆者又已經虛長了幾歲,前兩個問題仍然是心裡面不願面對的艱鉅挑戰,最後一個問題卻終於有一點心得;當然,那也是本系列、對於 ELF 的構造有興趣的讀者們必然也會感興趣的問題。
**程式從寫出來到編譯完再到可以跑,中間發生了什麼事情?**以 C 語言為例,最方便的指令 gcc 一鍵完成編譯,然後再於命令列執行產物執行檔就可以了。gcc 其實是一個 compiler driver,引用諸多工具以建立可執行檔,其中步驟包含:
#
開頭的指令處理掉,比方說引用標頭檔、展開巨集等。最後一個步驟,也就是今天的重點,連結
之前我們在 as 的範例中,完成了 add.o
的物件檔輸出之後,我們還需要仰賴以下指令:
$ riscv64-unknown-linux-gnu-ld --sysroot=/home/riscv/sysroot -dynamic-linker /lib/ld-linux-riscv64-lp64d.so.1 add.o main.o -o main /home/riscv/sysroot/usr/lib/crt1.o -lc
其中參數請參考第七日的說明。
我們這個領域應該非常能夠體會,沒有人是一個人在戰鬥,所有的人都站在巨人的肩膀上,或是得知於人者太多。總之,我們雖然寫程式,但往往產出的東西都是集非常多歷史的積累才得以完成。其中有前人的函式庫(比方說標準 C 函式庫)、關鍵的系統檔案(crt1.o)、開發的部份(比方說這裡的 add.o 物件檔是我們開發的部份)、既有的程式本體(比方說這裡的 main.c,add.o 的功能相當於它需要的外掛)。
連結正是如其字面意義一般,將這些互不隸屬,甚至可能根本就沒有什麼關係的物件檔,一個一個按照其中的引用關係,將之連結起來,就好像是在說:main
函式呼叫了 add
函式?行,等到處理完 add.o 就能夠幫你處理好那個呼叫。
那麼 main.o 原本長什麼樣子呢?大概如下:
$ riscv64-unknown-linux-gnu-objdump -dr ~/main.o
/root/main.o: file format elf64-littleriscv
Disassembly of section .text:
0000000000000000 <main>:
0: 1141 addi sp,sp,-16
0: R_RISCV_ALIGN *ABS*
2: e406 sd ra,8(sp)
4: e022 sd s0,0(sp)
6: 0800 addi s0,sp,16
8: 4589 li a1,2
a: 4505 li a0,1
c: 00000097 auipc ra,0x0
c: R_RISCV_CALL add
c: R_RISCV_RELAX *ABS*
10: 000080e7 jalr ra
14: 87aa mv a5,a0
16: 85be mv a1,a5
18: 000007b7 lui a5,0x0
18: R_RISCV_HI20 .LC0
18: R_RISCV_RELAX *ABS*
1c: 00078513 mv a0,a5
1c: R_RISCV_LO12_I .LC0
1c: R_RISCV_RELAX *ABS*
20: 00000097 auipc ra,0x0
20: R_RISCV_CALL printf
20: R_RISCV_RELAX *ABS*
24: 000080e7 jalr ra
28: 4781 li a5,0
2a: 853e mv a0,a5
2c: 60a2 ld ra,8(sp)
2e: 6402 ld s0,0(sp)
30: 0141 addi sp,sp,16
32: 8082 ret
原本的程式碼則是:
#include<stdio.h>
extern int add(int a, int b);
int main(){
printf("%d\n", add(1, 2));
}
這裡非常有趣的是 -r
參數第一次在本系列文中登場,代表重定位項目(relocation)。這裡可以看到剛才提到的 add
與 printf
函數的標籤都有出現在 R_RISCV_CALL
項目之後,但儘管如此,他們前後都只有最陽春的 auipc-jalr 指令對,前者沒有配給高位 20 位元給 ra 暫存器,後者也沒有計算相關的 offset。
這些東西都連結成功之後,變成這樣(使用 objdump 工具觀察產出的可執行檔):
...
0000000000010430 <main>:
10430: 1141 addi sp,sp,-16
10432: e406 sd ra,8(sp)
10434: e022 sd s0,0(sp)
10436: 0800 addi s0,sp,16
10438: 4589 li a1,2
1043a: 4505 li a0,1
1043c: fe1ff0ef jal ra,1041c <add>
10440: 87aa mv a5,a0
10442: 85be mv a1,a5
10444: 67c1 lui a5,0x10
10446: 4b878513 addi a0,a5,1208 # 104b8 <__libc_csu_fini+0x6>
1044a: f07ff0ef jal ra,10350 <printf@plt>
1044e: 4781 li a5,0
10450: 853e mv a0,a5
10452: 60a2 ld ra,8(sp)
10454: 6402 ld s0,0(sp)
10456: 0141 addi sp,sp,16
10458: 8082 ret
...
原本需要重定位的地方,這裡都已經變成個 jal 指令,並且指向具體的目標位址了!不僅如此,還有許多奇奇怪怪的東西,比方說,為什麼不是呼叫到 printf
函式,而是什麼 printf@plt
?為什麼 lui 指令原本後面的 mv 變成了 addi?怎麼會這樣?這就讓我們留到之後再慢慢探討吧。
還記得那位不知名學弟的問題嗎?他可不是只有問到可執行檔形成之時而已,他的好奇心更進入到執行,那個部份又怎麼樣呢?說來慚愧,在筆者原本還沒有被鐵人賽的艱辛壓垮之前,原本是預計挑戰這最後一片拼圖的,那就是動態連結器。其實我們也看過幾次,尤其是在一開始展示 readelf 輸出結果時。
我們時常可以看見一個特殊的區段叫做 .interp
,它的內容只有一個 C 語言字串,以我們之前用過的可執行檔為例,裡面的值是 /lib/ld-linux-riscv64-lp64d.so.1
。這個檔案是一個非常特殊的 ELF 檔,它能夠讀取一個 ELF 可執行檔,替該檔案完成動態連結函式庫的載入,然後再將程式的執行權交付給該程式的進入點位址。
無論如何,這是更困難的挑戰,因而也無法在這個鐵人賽的系列期間完成嘗試,但筆者有一天會試著挑戰看看的。
今天簡單介紹了連結的意義,還有一般的使用方法。明天我們繼續就這個主題深入探討 RISC-V 相關的規矩。各位讀者,我們明天再會!